POST /api/v1/conflicts/detect 提交任務,GET /api/v1/conflicts/{job_id} 查詢結果。功能都有了,但使用方式是這樣:curl -X POST http://localhost:8000/api/v1/conflicts/detect -H "Content-Type: application/json" \
-d '{"constraints": [{"id": "REQ-1", "text": "..."}, ...]}'
curl http://localhost:8000/api/v1/conflicts/job_20260925_194511_6da524
寫需求的人(PM、系統分析師)不會這樣用。今天要做一個網頁:貼上需求、按一個按鈕,就能看到衝突表格。
第 2 週:檢測優化與生產系統
今天要完成:
frontend/apiClient.js:統一處理請求與錯誤訊息frontend/components/DetectPage.jsx:輸入需求 → 提交 → 每秒輪詢 → 顯示結果frontend/components/ConflictTable.jsx:並列兩條需求原文、類型、嚴重度、置信度預期結果:在 http://localhost:3000 輸入 4 條需求,按下「開始檢測」後看到 2 個衝突。
把 curl 的流程翻成使用者操作,前端要處理四件事:
| curl 流程 | 前端要做的 |
|---|---|
手寫 JSON [{"id": "REQ-1", "text": ...}] |
使用者一行一條輸入,前端自動編號 |
POST 拿到 job_id |
顯示「任務已提交」 |
反覆 GET 直到 completed |
輪詢:每秒查一次,完成或失敗就停 |
讀 JSON 裡的 conflicts |
用表格呈現,並把 REQ-1 換回需求原文 |
為什麼要輪詢?Day 13 的 API 是任務模式:規則模式幾毫秒就完成,但 Mistral 7B 模式一份 SRS 要十幾秒。前端不能假設「提交完就有結果」。
另一個問題是跨域。前端開發伺服器跑在 localhost:3000,API 在 localhost:8000,瀏覽器會把它們視為不同來源。Day 13 已經在後端開了 CORS,但這裡改用 Vite 的代理:前端一律呼叫相對路徑 /api/v1/...,由 Vite 轉發給後端。好處是前端程式碼不用寫死後端網址,正式部署時把前端和 API 放在同一個網域即可。
使用者輸入(每行一條)
↓ parseConstraints()
DetectPage ── api.submit(lines) ──→ POST /api/v1/conflicts/detect ──→ job_id
↓
└─ 每秒 api.getJob(job_id) ──→ GET /api/v1/conflicts/{job_id}
status = queued / processing → 繼續輪詢
status = completed → ConflictTable 顯示 results.conflicts
status = failed → 顯示 error
frontend/ 目錄(新增)src/api_main.py 必須在 localhost:8000 執行DetectPage 與 ConflictTable,只換掉 API 客戶端專案結構:
srs-review-agent/
├── src/api_main.py ← Day 13(後端)
└── frontend/ ← 【新增】
├── package.json
├── vite.config.js ← 開發伺服器與 /api/ 代理
├── index.html
├── index.jsx ← 入口
├── DetectApp.jsx ← 今天的根元件
├── apiClient.js ← API 客戶端
└── components/
├── DetectPage.jsx ← 輸入、提交、輪詢
└── ConflictTable.jsx ← 結果表格
完成版的 repo 裡還有
frontend/App.jsx,那是 Day 16 加入登入功能後的根元件。今天用不到它。
需要 Node.js 18 以上(Vite 5 的要求)。macOS 可以用 Homebrew 安裝:
brew install node
node -v # 本文使用 v26.10.0
建立 frontend/package.json:
{
"name": "srs-review-agent-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@mui/material": "^5.15.0",
"@mui/icons-material": "^5.15.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
}
}
沒有用 axios:瀏覽器內建的 fetch 已經夠用,少一個依賴。@emotion/* 是 Material-UI 5 的樣式引擎,必須一起安裝。
建立 frontend/vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
// 結尾的斜線很重要:寫成 '/api' 會連 /apiClient.js 這類前端檔案也轉給後端
'/api/': 'http://localhost:8000'
}
}
})
這個斜線是我實際踩到的坑。Vite 的 proxy 用前綴比對,一開始寫成 '/api',結果瀏覽器載入 /apiClient.js 時也被轉給 FastAPI,回了 404,整個頁面一片空白。npm run build 不會經過代理,所以 build 成功並不代表開發伺服器能正常跑。
frontend/index.html 只有一個 <div id="root"> 和載入 /index.jsx 的 <script type="module">,frontend/index.jsx 則把根元件掛上去:
import React from 'react'
import ReactDOM from 'react-dom/client'
import DetectApp from './DetectApp'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<DetectApp />
</React.StrictMode>,
)
完成版 repo 的
frontend/index.jsx會依環境變數選擇根元件:預設是 Day 16 的App,設定VITE_PUBLIC_MODE=1時才是今天的DetectApp。
建立 frontend/apiClient.js:
const API_BASE = '/api/v1'
async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, options)
const data = await response.json().catch(() => ({}))
if (!response.ok) {
// 後端錯誤一律帶 detail 欄位(見 Day 13 的 http_exception_handler)
throw new Error(data.detail || `HTTP ${response.status}`)
}
return data
}
// 每行一條需求,忽略空行
export function parseConstraints(text) {
return text.split('\n').map((line) => line.trim()).filter(Boolean)
}
export const publicApi = {
submit: (lines) =>
request('/conflicts/detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
constraints: lines.map((text, i) => ({ id: `REQ-${i + 1}`, text })),
}),
}),
getJob: (jobId) => request(`/conflicts/${jobId}`),
}
request() 把「HTTP 狀態不是 2xx」轉成例外,並優先使用後端的 detail 當錯誤訊息。Day 13 特地讓錯誤回應帶上 detail,就是為了這裡:使用者會看到「至少需要 2 個約束」,而不是籠統的「檢測失敗」。
publicApi 把 API 包成 submit / getJob 兩個函數。元件只依賴這兩個函數,不知道實際的網址,Day 16 換成登入版 API 時元件不用改。(完成版的檔案裡還有 Day 16 的 authApi()。)
建立 frontend/components/DetectPage.jsx。先看提交的部分:
export default function DetectPage({ api, onSubmitted }) {
const [text, setText] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [jobId, setJobId] = useState(null)
const [job, setJob] = useState(null)
const [texts, setTexts] = useState({})
const handleSubmit = async () => {
const lines = parseConstraints(text)
setSubmitting(true)
setError('')
setJob(null)
try {
const data = await api.submit(lines)
// 記下 REQ 編號對應的原文,結果表格用來顯示需求內容
setTexts(Object.fromEntries(lines.map((t, i) => [`REQ-${i + 1}`, t])))
setJobId(data.job_id)
onSubmitted?.(data.job_id)
} catch (err) {
setError(err.message)
} finally {
setSubmitting(false)
}
}
API 回傳的衝突只有 REQ-1、REQ-3 這種編號,使用者看不懂。所以提交時順便記下「編號 → 原文」的對照表 texts,交給結果表格使用。
按鈕在需求少於 2 條時停用,並顯示目前條數:
<Button fullWidth variant="contained" onClick={handleSubmit}
disabled={submitting || lineCount < 2}>
{submitting ? <CircularProgress size={24} /> : `🔍 開始檢測(${lineCount} 條)`}
</Button>
後端也會檢查「至少 2 條」,但前端先擋下來,使用者不用等一次來回才知道錯在哪。
同一個檔案中,用 useEffect 在 jobId 改變時開始輪詢:
const POLL_INTERVAL_MS = 1000
useEffect(() => {
if (!jobId) return
let cancelled = false
let timer
const poll = async () => {
try {
const data = await api.getJob(jobId)
if (cancelled) return
setJob(data)
if (data.status !== 'completed' && data.status !== 'failed') {
timer = setTimeout(poll, POLL_INTERVAL_MS)
}
} catch (err) {
if (!cancelled) setError(err.message)
}
}
poll()
// 換任務或離開頁面時停止輪詢,避免對舊任務繼續發請求
return () => {
cancelled = true
clearTimeout(timer)
}
}, [jobId, api])
幾個設計考量:
setTimeout 串接,而不是 setInterval:上一次請求回來才排下一次。後端很慢時,setInterval 會讓請求堆積completed 或 failed 就停,不會永遠輪詢下去cancelled 旗標確保舊請求回來時不會覆蓋新任務的畫面api:傳入的 api 物件必須是穩定的(今天的 publicApi 是模組層級常數),否則每次 render 都會重新開始輪詢DetectPage 依狀態顯示不同內容:
{job?.status === 'failed' && <Alert severity="error">檢測失敗:{job.error}</Alert>}
{job?.status === 'completed' && (
conflicts.length === 0
? <Alert severity="success">沒有檢測到衝突</Alert>
: (
<>
<Typography sx={{ mb: 1 }}>發現 {conflicts.length} 個衝突:</Typography>
<ConflictTable conflicts={conflicts} texts={texts} />
</>
)
)}
狀態標籤(queued / processing / completed / failed)與處理中的轉圈動畫,完整代碼見 frontend/components/DetectPage.jsx。
建立 frontend/components/ConflictTable.jsx,每一列並列兩條需求:
const SEVERITY_COLOR = { 高: 'error', 中: 'warning', 低: 'default' }
{conflicts.map((c) => (
<TableRow key={`${c.req_id_1}-${c.req_id_2}-${c.description}`}>
<TableCell>
<div><b>{c.req_id_1}</b> {texts[c.req_id_1]}</div>
<div><b>{c.req_id_2}</b> {texts[c.req_id_2]}</div>
</TableCell>
<TableCell>{c.type}</TableCell>
<TableCell>
<Chip label={c.severity} color={SEVERITY_COLOR[c.severity] || 'default'} size="small" />
</TableCell>
<TableCell>{c.description}</TableCell>
<TableCell>{Math.round(c.confidence * 100)}%</TableCell>
<TableCell>{c.verified ? '✅' : '—'}</TableCell>
</TableRow>
))}
「LLM 驗證」欄對應 API 的 verified:規則模式下是 —,Mistral 模式下經過批量驗證的會顯示 ✅。Day 12 提過,verified 只代表「經過 LLM 看過」,不代表一定正確,所以表格照實呈現,不額外加上「已確認」之類的字眼。
建立 frontend/DetectApp.jsx:
import React from 'react'
import { AppBar, Toolbar, Typography, Container } from '@mui/material'
import DetectPage from './components/DetectPage'
import { publicApi } from './apiClient'
export default function DetectApp() {
return (
<>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>📋 SRS 審查 Agent</Typography>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
<DetectPage api={publicApi} />
</Container>
</>
)
}
DetectPage 透過 api 參數取得 submit / getJob,而不是自己 import publicApi。這樣它不會綁定特定端點,Day 16 只要傳入登入版的 API 物件就能重用。
開兩個終端機:
# 終端 1:後端(專案根目錄)
uvicorn src.api_main:app --port 8000
# 終端 2:前端
cd frontend
npm install
npm run dev # 完成版 repo 請用:VITE_PUBLIC_MODE=1 npm run dev
VITE v5.4.21 ready in 70 ms
➜ Local: http://localhost:3000/
也可以確認正式版能 build:
npm run build
vite v5.4.21 building for production...
✓ 914 modules transformed.
dist/index.html 0.31 kB │ gzip: 0.25 kB
dist/assets/index-D9cBnabO.js 378.31 kB │ gzip: 117.31 kB
✓ built in 629ms
打開 http://localhost:3000,在文字框輸入:
系統支持多用戶並行存取
系統採用單用戶模式
所有數據必須加密存儲
使用明文存儲以提高性能
按鈕從「開始檢測(0 條)」變成「開始檢測(4 條)」並可點擊。按下後,下方出現「任務狀態 completed」與:
| 需求 | 類型 | 嚴重度 | 說明 | 置信度 | LLM 驗證 |
|---|---|---|---|---|---|
| REQ-1 系統支持多用戶並行存取REQ-2 系統採用單用戶模式 | 邏輯矛盾 | 高 | 檢測到 多用戶 vs 單用戶 | 95% | — |
| REQ-3 所有數據必須加密存儲REQ-4 使用明文存儲以提高性能 | 安全性衝突 | 高 | 檢測到 加密 vs 明文 | 95% | — |
我用 Playwright 驅動 headless Chromium 自動跑了這個流程,確認:
POST /api/v1/conflicts/detect 與 1 次 GET /api/v1/conflicts/{job_id},任務完成後沒有再輪詢把後端換成 SRS_USE_OLLAMA=1 uvicorn src.api_main:app --port 8000,輸入 Day 13 那份 5 條需求的電商 SRS:
支持明文顯示訂單細節
不需要加密用戶的個人信息
所有支付數據必須加密傳輸
購物車數據實時同步到伺服器
支持本地離線購物車
processing,旁邊有轉圈動畫其中補充層回報的「所有支付數據必須加密傳輸 vs 支持本地離線購物車」,說明欄是 Mistral 產生的英文句子。這和 Day 13 看到的結果相同:補充層的判斷與輸出語言都不穩定,需要人工複核。
src/chunking.py 能切分 Markdown SRS,但 API 目前只收約束清單。「上傳 .md → 自動抽出需求」需要 API 新增端點,列為可選延伸git add frontend/package.json frontend/vite.config.js frontend/index.html frontend/index.jsx \
frontend/DetectApp.jsx frontend/apiClient.js frontend/components/
git commit -m "Day 14: React 前端(輸入需求、輪詢任務、衝突表格)"
frontend/node_modules/ 與 frontend/dist/ 是安裝與 build 的產物,不要提交。
現在任何人打開網頁都能送出檢測,結果也只存在記憶體裡。明天 Day 15 會加入用戶系統:註冊與登入(JWT)、密碼雜湊儲存,並把任務寫進資料庫,讓每個用戶都有自己的檢測歷史。